import { showBalloonMessage } from "../../debug/debug.js"; import type { Context } from "../../engine_context.js"; import { $GPtE, _bQKz, Telemetry } from "../../engine_license.js"; import { isLocalNetwork } from "../../engine_networking_utils.js"; import { HTMLElementBase } from "../../engine_ssr.js"; import { DeviceUtilities, getParam } from "../../engine_utils.js"; import { onXRSessionStart, XRSessionEventArgs } from "../../xr/events.js"; import { ButtonsFactory } from "../buttons.js"; import { ensureFonts, iconFontUrl, loadFont } from "../fonts.js"; import { getIconElement } from "../icons.js"; import { NeedleLogoElement } from "../logo-element.js"; import { getElementPriority, setElementPriority as _setElementPriority } from "./menu-priority.js"; import { NeedleSpatialMenu } from "./needle-menu-spatial.js"; // NOTE: keep JSX attributes in sync with ../jsx.d.ts declare global { interface HTMLElementTagNameMap { "needle-menu": NeedleMenuElement; } } const elementName = "needle-menu"; const debug = getParam("debugmenu"); const debugNonCommercial = getParam("debugnoncommercial"); /** This is the model for the postMessage event that the needle engine will send to create menu items */ export declare type NeedleMenuPostMessageModel = { type: "needle:menu", button?: { label?: string, /** Google icon name */ icon?: string, /** currently only URLs are supported */ onclick?: string, target?: "_blank" | "_self" | "_parent" | "_top", /** Low priority is icon is on the left, high priority is icon is on the right. Default is 0 */ priority?: number, } } /** * Used by the NeedleMenuElement to create a button at {@link NeedleMenuElement#appendChild} */ export declare type ButtonInfo = { /** Invoked when the button is clicked */ onClick: (evt: Event) => void, /** Visible button text */ label: string, /** Material icon name: https://fonts.google.com/icons */ icon?: string, /** "left" or "right" to place the icon on the left or right side of the button. Default is "left" */ iconSide?: "left" | "right", /** * Priority controls the order of buttons in the menu. * If not enough space is available to show all buttons - the highest priority elements will always be visible * * **Sorting** * Low priority is icon is on the left, * high priority is icon is on the right. * @default undefined */ priority?: number; /** Experimental. Allows to put two buttons in one row for the compact layout */ class?: "row2"; title?: string; } /** * The NeedleMenu is a menu that can be displayed in the needle engine webcomponent or in VR/AR sessions. * * The menu can be used to add buttons to the needle engine that can be used to interact with the application. * * The menu can be positioned at the top or the bottom of the webcomponent. * * @example Add a new button using the NeedleMenu * ```typescript * onStart(ctx => { * ctx.menu.appendChild({ * label: "Open Google", * icon: "google", * onClick: () => { window.open("https://www.google.com", "_blank") } * }); * }) * ``` * * Buttons can be added to the menu using the {@link NeedleMenu#appendChild} method or by sending a postMessage event to the needle engine with the type "needle:menu". Use the {@link NeedleMenuPostMessageModel} model to create buttons with postMessage. * @example Create a button using a postmessage * ```javascript * window.postMessage({ * type: "needle:menu", * button: { * label: "Open Google", * icon: "google", * onclick: "https://www.google.com", * target: "_blank", * } * }, "*"); * ``` * * @example Access the menu from a component * ```typescript * import { Behaviour, OnStart } from '@needle-tools/engine'; * * export class MyComponent extends Behaviour { * * start() { * this.context.menu.appendChild({ ... }); * } * } * ``` * * @category HTML */ export class NeedleMenu { static setElementPriority(button: HTMLElement, priority: number) { _setElementPriority(button, priority); } static getElementPriority(button: HTMLElement): number | undefined { return getElementPriority(button); } private readonly _context: Context; private readonly _menu: NeedleMenuElement; private readonly _spatialMenu: NeedleSpatialMenu; constructor(context: Context) { this._menu = NeedleMenuElement.getOrCreate(context.domElement, context); this._menu.ensureInitialized(); this._context = context; this._spatialMenu = new NeedleSpatialMenu(context, this._menu); window.addEventListener("message", this.onPostMessage); onXRSessionStart(this.onStartXR); } /** @ignore internal method */ onDestroy() { window.removeEventListener("message", this.onPostMessage); this._menu.remove(); this._spatialMenu.onDestroy(); } private onPostMessage = (e: MessageEvent) => { // lets just allow the same origin for now if (e.origin !== globalThis.location.origin) return; if (typeof e.data === "object") { const data = e.data as NeedleMenuPostMessageModel; const type = data.type; if (type === "needle:menu") { const buttoninfo = data.button; if (buttoninfo) { if (!buttoninfo.label) return console.error("NeedleMenu: buttoninfo.label is required"); if (!buttoninfo.onclick) return console.error("NeedleMenu: buttoninfo.onclick is required"); const button = document.createElement("button"); button.textContent = buttoninfo.label; if (buttoninfo.icon) { const icon = getIconElement(buttoninfo.icon); button.prepend(icon); } if (buttoninfo.priority) { button.setAttribute("priority", buttoninfo.priority.toString()); } button.onclick = () => { if (buttoninfo.onclick) { const isLink = buttoninfo.onclick.startsWith("http") || buttoninfo.onclick.startsWith("www."); const target = buttoninfo.target || "_blank"; if (isLink) { globalThis.open(buttoninfo.onclick, target); } else console.error("NeedleMenu: onclick is not a valid link", buttoninfo.onclick); } } Telemetry.sendEvent(this._context, "needle-menu", { action: "button_added_via_postmessage", }); this._menu.appendChild(button); } else if (debug) console.error("NeedleMenu: unknown postMessage event", data); } else if (debug) console.warn("NeedleMenu: unknown postMessage type", type, data); } }; private onStartXR = (args: XRSessionEventArgs) => { if (args.session.isScreenBasedAR) { this._menu["previousParent"] = this._menu.parentNode; this._context.arOverlayElement.appendChild(this._menu); // The host defaults to z-index 0 (to not overlay elements outside in 2D). // In screen-based AR the overlay may be reparented to document.body (e.g. Needle Go App Clip / // Mozilla XR) where the WebXR polyfill's fullscreen canvas wrapper sits at z-index 1 and would // render on top of the menu — visible through the transparent canvas but swallowing all taps. this._menu.style.zIndex = "9999"; args.session.session.addEventListener("end", this.onExitXR); // Close the foldout if it's open on entering AR this._menu.closeFoldout(); } } private onExitXR = () => { this._menu.style.zIndex = ""; if (this._menu["previousParent"]) { this._menu["previousParent"].appendChild(this._menu); delete this._menu["previousParent"]; } } /** Experimental: Change the menu position to be at the top or the bottom of the needle engine webcomponent * @param position "top" or "bottom" */ setPosition(position: "top" | "bottom") { this._menu.setPosition(position); } /** * Call to show or hide the menu. * NOTE: Hiding the menu requires a commercial license. Hiding the menu will not work in production without a license. */ setVisible(visible: boolean) { this._menu.setVisible(visible); } /** When set to false, the Needle Engine logo will be hidden. Hiding the logo requires a commercial license */ showNeedleLogo(visible: boolean) { this._menu.showNeedleLogo(visible); this._spatialMenu?.showNeedleLogo(visible); // setTimeout(()=>this.showNeedleLogo(!visible), 1000); } /** @returns true if the logo is visible */ get logoIsVisible() { return this._menu.logoIsVisible; } /** When enabled=true the menu will be visible in VR/AR sessions */ showSpatialMenu(enabled: boolean) { this._spatialMenu.setEnabled(enabled); } setSpatialMenuVisible(display: boolean) { this._spatialMenu.setDisplay(display); } get spatialMenuIsVisible() { return this._spatialMenu.isVisible; } /** * Call to add or remove a button to the menu to show a QR code for the current page * If enabled=true then a button will be added to the menu that will show a QR code for the current page when clicked. */ showQRCodeButton(enabled: boolean | "desktop-only"): HTMLButtonElement | null { if (enabled === "desktop-only") { enabled = !DeviceUtilities.isMobileDevice(); } if (!enabled) { const button = ButtonsFactory.getOrCreate().qrButton; if (button) button.style.display = "none"; return button ?? null; } else { const button = ButtonsFactory.getOrCreate().createQRCode(); button.style.display = ""; this._menu.appendChild(button); return button; } } /** Call to add or remove a button to the menu to mute or unmute the application * Clicking the button will mute or unmute the application */ showAudioPlaybackOption(visible: boolean): void { if (!visible) { this._muteButton?.remove(); return; } this._muteButton = ButtonsFactory.getOrCreate().createMuteButton(this._context); this._menu.appendChild(this._muteButton); } private _muteButton?: HTMLButtonElement; showFullscreenOption(visible: boolean): void { if (!visible) { this._fullscreenButton?.remove(); return; } this._fullscreenButton = ButtonsFactory.getOrCreate().createFullscreenButton(this._context); if (this._fullscreenButton) { this._menu.appendChild(this._fullscreenButton); } } private _fullscreenButton?: HTMLButtonElement | null; appendChild(child: HTMLElement | ButtonInfo) { return this._menu.appendChild(child); } } // #region Web component /** * `` web component — lightweight menu used by Needle Engine. * * This element is intended as an internal UI primitive for hosting application * menus and buttons. Use the higher-level `NeedleMenu` API from the engine * code to manipulate it programmatically. Public DOM-facing methods are * documented (appendChild / append / prepend / setPosition / setVisible). * * @element needle-menu */ export class NeedleMenuElement extends HTMLElementBase { static create() { // Ensure the element is registered before creating — guards against // cases where this is called before initWebComponents() has run. if (!customElements.get(elementName)) customElements.define(elementName, NeedleMenuElement); return document.createElement(elementName); } static getOrCreate(domElement: HTMLElement, context: Context) { let element = domElement.querySelector(elementName) as NeedleMenuElement | null; if (!element && domElement.shadowRoot) { element = domElement.shadowRoot.querySelector(elementName); } // if no needle-menu was found in the domelement then we search the document body if (!element) { element = window.document.body.querySelector(elementName) as NeedleMenuElement | null; } if (!element) { // OK no menu element exists yet anywhere element = NeedleMenuElement.create() as NeedleMenuElement; if (domElement.shadowRoot) domElement.shadowRoot.appendChild(element); else domElement.appendChild(element); } element._domElement = domElement; element._context = context; return element as NeedleMenuElement; } private _domElement: HTMLElement | null = null; private _context: Context | null = null; private _didInitialize = false; constructor() { super(); } private initializeDom() { const template = document.createElement('template'); // TODO: make host full size again and move the buttons to a wrapper so that we can later easily open e.g. foldouts/dropdowns / use the whole canvas space template.innerHTML = `
`; // we dont need to expose the shadow root const shadow = this.attachShadow({ mode: 'open' }); // we need to add the icons to both the shadow dom as well as the HEAD to work // https://github.com/google/material-design-icons/issues/1165 ensureFonts(); loadFont(iconFontUrl, { loadedCallback: () => { this.handleSizeChange() } }); loadFont(iconFontUrl, { element: shadow }); const content = template.content.cloneNode(true) as DocumentFragment; shadow?.appendChild(content); this.root = shadow.querySelector("#root") as HTMLDivElement; this.wrapper = this.root?.querySelector(".wrapper") as HTMLDivElement; this.options = this.root?.querySelector(".options.main-container") as HTMLDivElement; this.optionsCompactMode = this.root?.querySelector(".options.compact-only") as HTMLDivElement; this.logoContainer = this.root?.querySelector(".logo") as HTMLDivElement; this.compactMenuButton = this.root?.querySelector(".compact-menu-button") as HTMLButtonElement; this.compactMenuButton.append(getIconElement("more_vert")); this.foldout = this.root?.querySelector(".foldout") as HTMLDivElement; this.root?.appendChild(this.wrapper); this.wrapper.classList.add("wrapper"); const logo = NeedleLogoElement.create(); logo.setType("compact"); logo.style.minHeight = "1rem"; this.logoContainer.append(logo); this.logoContainer.addEventListener("click", () => { globalThis.open("https://needle.tools", "_blank"); }); try { // if the user has a license then we CAN hide the needle logo // calling this method immediately will cause an issue with vite bundling tho window.requestAnimationFrame(() => _bQKz(res => { if (res == true && $GPtE() && !debugNonCommercial) { let visible = this._userRequestedLogoVisible; if (visible === undefined) visible = false; this.___onSetLogoVisible(visible); } else { this.___onSetLogoVisible(true); } })); } catch (e) { console.error("[Needle Menu] License check failed.", e); } this.compactMenuButton.addEventListener("click", evt => { evt.preventDefault(); this.root.classList.toggle("open"); }); let context = this._context; // we need to assign it in the timeout because the reference is set *after* the element is initialized setTimeout(() => context = this._context); // watch changes let changeEventCounter = 0; const forceVisible = (parent, visible) => { if (debug) console.log("Set menu visible", visible); if (context?.isInAR && context.arOverlayElement) { if (parent != context.arOverlayElement) { context.arOverlayElement.appendChild(this); } } else if (this.parentNode != this._domElement?.shadowRoot) this._domElement?.shadowRoot?.appendChild(this); this.style.display = visible ? "flex" : "none"; this.style.visibility = "visible"; this.style.opacity = "1"; } let isHandlingMutation = false; const rootObserver = new MutationObserver(mutations => { if (isHandlingMutation) { return; } try { isHandlingMutation = true; this.onChangeDetected(mutations); // ensure the menu is not hidden or removed const requiredParent = this?.parentNode; if (this.style.display != "flex" || this.style.visibility != "visible" || this.style.opacity != "1" || requiredParent != this._domElement?.shadowRoot) { if (!$GPtE()) { const change = changeEventCounter++; // if a user doesn't have a local pro license *but* for development the menu is hidden then we show a warning if (isLocalNetwork() && this._userRequestedMenuVisible === false) { // set visible once so that the check above is not triggered again if (change === 0) { // if the user requested visible to false before this code is called for the first time we want to respect the choice just in this case forceVisible(requiredParent, this._userRequestedMenuVisible); } // warn only once if (change === 1) { console.warn(`Needle Menu Warning: You need a commercial license to hide the Needle Engine menu → The menu will be visible in your deployed website without one. See https://needle.tools/pricing for details.`); } } else { if (change === 0) { forceVisible(requiredParent, true); } else { setTimeout(() => forceVisible(requiredParent, true), 5) } } } } } finally { isHandlingMutation = false; } }); rootObserver.observe(this.root, { childList: true, subtree: true, attributes: true }); if (debug) { this.___insertDebugOptions(); } } ensureInitialized() { if (!this._didInitialize) { this._didInitialize = true; this.initializeDom(); } } private _sizeChangeInterval; connectedCallback() { this.ensureInitialized(); window.addEventListener("resize", this.handleSizeChange); this.addEventListener("beforexrselect", this.#onBeforeXRSelect); this.handleMenuVisible(); this._sizeChangeInterval = setInterval(() => this.handleSizeChange(undefined, false), 5000); // the dom element is set after initialization runs setTimeout(() => { this._domElement?.addEventListener("resize", this.handleSizeChange); this._domElement?.addEventListener("click", this.#onClick); }, 1) } disconnectedCallback() { window.removeEventListener("resize", this.handleSizeChange); this.removeEventListener("beforexrselect", this.#onBeforeXRSelect); clearInterval(this._sizeChangeInterval); this._domElement?.removeEventListener("resize", this.handleSizeChange); this._context?.domElement.removeEventListener("click", this.#onClick); } /** Prevent XR select from firing into the scene (e.g. tap-to-place) when the user * interacts with the menu during screen-based AR (dom-overlay / Needle Go App Clip) */ #onBeforeXRSelect = (e: Event) => { e.preventDefault(); } #onClick = (e: Event) => { // detect a click outside the opened foldout to automatically close it if (!e.defaultPrevented && e.target == this._domElement && (e instanceof PointerEvent && e.button === 0) && this.root.classList.contains("open")) { // The menu is open, it's a click outside the foldout? const rect = this.foldout.getBoundingClientRect(); const pointerEvent = e as PointerEvent; if (!(pointerEvent.clientX > rect.left && pointerEvent.clientX < rect.right && pointerEvent.clientY > rect.top && pointerEvent.clientY < rect.bottom)) { this.root.classList.toggle("open", false); } } } /** @private user preference for logo visibility */ private _userRequestedLogoVisible?: boolean = undefined; showNeedleLogo(visible: boolean) { this._userRequestedLogoVisible = visible; if (!visible) { if (!$GPtE() || debugNonCommercial) { console.warn("[Needle Engine] You need a commercial license to hide the Needle Engine logo in production."); const localNetwork = isLocalNetwork() if (!localNetwork) return; } } this.___onSetLogoVisible(visible); } /** @returns true if the logo is visible */ get logoIsVisible() { return !this.root.classList.contains("logo-hidden"); } private ___onSetLogoVisible(visible: boolean) { this.logoContainer.style.display = ""; this.logoContainer.style.opacity = "1"; this.logoContainer.style.visibility = "visible"; if (visible) { this.root.classList.remove("logo-hidden"); this.root.classList.add("logo-visible"); } else { this.root.classList.remove("logo-visible"); this.root.classList.add("logo-hidden"); } } setPosition(position: "top" | "bottom") { // ensure the position is of a known type: if (position !== "top" && position !== "bottom") { return console.error("NeedleMenu.setPosition: invalid position", position); } this.root.classList.remove("top", "bottom"); this.root.classList.add(position); } /** @private user preference for menu visibility */ private _userRequestedMenuVisible?: boolean = undefined; setVisible(visible: boolean) { this._userRequestedMenuVisible = visible; this.style.display = visible ? "flex" : "none"; } /** * If the menu is in compact mode and the foldout is currently open (to show all menu options) then this will close the foldout */ closeFoldout() { this.root.classList.remove("open"); } // private _root: ShadowRoot | null = null; /** @private root container element inside shadow DOM */ private root!: HTMLDivElement; /** @private wraps the whole content (internal layout) */ private wrapper!: HTMLDivElement; /** @private contains the buttons and dynamic elements */ private options!: HTMLDivElement; /** @private contains options visible when in compact mode */ private optionsCompactMode!: HTMLDivElement; /** @private contains the needle-logo html element */ private logoContainer!: HTMLDivElement; /** @private compact menu button element */ private compactMenuButton!: HTMLButtonElement; /** @private foldout container used in compact mode */ private foldout!: HTMLDivElement; private readonly trackedElements: WeakSet = new WeakSet(); private trackElement(el: Node) { if (this.trackedElements.has(el)) return; this.trackedElements.add(el); el.addEventListener("click", (evt) => { Telemetry.sendEvent(this._context, "needle-menu", { action: "button_clicked", element: evt.target instanceof Node ? evt.target.nodeName : el.nodeName, label: el.textContent, title: (el instanceof HTMLElement) ? el.title : undefined, pointerid: (evt instanceof PointerEvent) ? evt.pointerId : undefined, }); }); // el.addEventListener("pointerenter", (evt) => { // Telemetry.sendEvent(this._context, "needle-menu", { // action: "button_hovered", // element: evt.target instanceof Node ? evt.target.nodeName : el.nodeName, // label: el.textContent, // title: (el instanceof HTMLElement) ? el.title : undefined, // pointerid: (evt instanceof PointerEvent) ? evt.pointerId : undefined, // }); // }); } append(...nodes: (string | Node)[]): void { for (const node of nodes) { if (typeof node === "string") { const element = document.createTextNode(node); this.trackElement(element); this.options.appendChild(element); } else { this.trackElement(node); this.options.appendChild(node); } } } /** * Appends a button or HTML element to the needle-menu options. * @param node a Node or ButtonInfo to create a button from * @returns the appended Node * * @example Append a button * ```javascript * const button = document.createElement("button"); * button.textContent = "Click Me"; * needleMenu.appendChild(button); * ``` * @example Append a button using ButtonInfo * ```javascript * needleMenu.appendChild({ * label: "Click Me", * onClick: () => { alert("Button clicked!"); }, * icon: "info", * title: "This is a button", * }); * ``` */ appendChild(node: T | ButtonInfo): T { if (!(node instanceof Node)) { const button = document.createElement("button"); button.textContent = node.label; button.onclick = node.onClick; button.setAttribute("priority", node.priority?.toString() ?? "0"); if (node.title) { button.title = node.title; } if (node.icon) { const icon = getIconElement(node.icon); if (node.iconSide === "right") { button.appendChild(icon); } else { button.prepend(icon); } } if (node.class) { button.classList.add(node.class); } node = button as unknown as T; } this.trackElement(node); const res = this.options.appendChild(node); return res; } prepend(...nodes: (string | Node)[]): void { for (const node of nodes) { if (typeof node === "string") { const element = document.createTextNode(node); this.trackElement(element); this.options.prepend(element); } else { this.trackElement(node); this.options.prepend(node); } } } private _isHandlingChange = false; /** During modification of options container (e.g. when moving items into the extra buttons container) the mutation observer should not trigger an update event immediately. This is a workaround for the total size required for all elements not being calculated reliably. */ private _pauseMutationObserverOptionsContainer = false; /** Called when any change in the web component is detected (including in children and child attributes) */ private onChangeDetected(_mut: MutationRecord[]) { if (this._isHandlingChange) return; this._isHandlingChange = true; try { // if (debug) console.log("NeedleMenu.onChangeDetected", _mut); this.handleMenuVisible(); for (const mut of _mut) { if (mut.target == this.options) { if (!this._pauseMutationObserverOptionsContainer) this.onOptionsChildrenChanged(mut) } } } finally { this._isHandlingChange = false; } } private onOptionsChildrenChanged(_mut: MutationRecord) { this.root.classList.toggle("has-options", this.hasAnyVisibleOptions); this.root.classList.toggle("has-no-options", !this.hasAnyVisibleOptions); this.handleSizeChange(undefined, true); if (_mut.type === "childList") { if (_mut.addedNodes.length > 0) { const children = Array.from(this.options.children); children.sort((a, b) => { const p1 = parseInt(a.getAttribute("priority") || "0"); const p2 = parseInt(b.getAttribute("priority") || "0"); return p1 - p2; }); let sortingChanged = false; for (let i = 0; i < children.length; i++) { const existing = this.options.children[i]; const child = children[i]; if (existing !== child) { sortingChanged = true; break; } } if (sortingChanged) { for (const child of children) { this.options.appendChild(child); } } } } } private _didSort: Map = new Map(); /** checks if the menu has any content and should be rendered at all * if we dont have any content and logo then we hide the menu */ private handleMenuVisible() { if (debug) console.log("Update VisibleState: Any Content?", this.hasAnyContent); if (this.hasAnyContent) { this.root.style.display = ""; } else { this.root.style.display = "none"; } this.root.classList.toggle("has-options", this.hasAnyVisibleOptions); this.root.classList.toggle("has-no-options", !this.hasAnyVisibleOptions); } /** @returns true if we have any content OR a logo */ get hasAnyContent() { // is the logo visible? if (this.logoContainer.style.display != "none") return true; if (this.hasAnyVisibleOptions) return true; return false; } get hasAnyVisibleOptions() { // do we have any visible buttons? for (let i = 0; i < this.options.children.length; i++) { const child = this.options.children[i] as HTMLElement // is slot? if (child.tagName === "SLOT") { const slotElement = child as HTMLSlotElement; const nodes = slotElement.assignedNodes(); for (const node of nodes) { if (node instanceof HTMLElement) { if (node.style.display != "none") return true; } } } else if (child.style.display != "none") return true; } return false; } private _lastAvailableWidthChange = 0; private _timeoutHandleSize: number = 0; private _timeoutHandleCompactItems: number = 0; private handleSizeChange = (_evt?: Event, forceOrEvent?: boolean) => { if (!this._domElement) return; // if (this._isApplyingSizeUpdate) return; const width = this._domElement.clientWidth; if (width < 100) { clearTimeout(this._timeoutHandleSize!); this.root.classList.add("compact"); this.foldout.classList.add("floating-panel-style"); return; } const padding = 10 * 2; const availableWidth = width - padding; // if the available width has not changed significantly then we can skip the rest if (!forceOrEvent && Math.abs(availableWidth - this._lastAvailableWidthChange) < 1) return; this._lastAvailableWidthChange = availableWidth; clearTimeout(this._timeoutHandleSize!); this._timeoutHandleSize = setTimeout(() => { // console.warn("APPLY", this.root.classList.contains("compact") ? "COMPACT" : "FULL", "MODE (available width: " + availableWidth.toFixed(0) + "px)", getCurrentWidth()); const spaceLeft = getSpaceLeft(); if (spaceLeft < 0) { this.root.classList.add("compact") this.foldout.classList.add("floating-panel-style"); } else if (spaceLeft > 0) { this.root.classList.remove("compact") this.foldout.classList.remove("floating-panel-style"); if (getSpaceLeft() < 0) { // ensure we still have enough space left this.root.classList.add("compact") this.foldout.classList.add("floating-panel-style"); } } this._pauseMutationObserverOptionsContainer = true; this.updateCompactFoldoutItem(); window.requestAnimationFrame(() => this._pauseMutationObserverOptionsContainer = false); }, 150) as unknown as number; const getCurrentWidth = () => { let totalWidthRequired = 0; totalWidthRequired += this.options.getBoundingClientRect().width; totalWidthRequired += this.optionsCompactMode.getBoundingClientRect().width; totalWidthRequired += 10 * this.options.childElementCount; // padding totalWidthRequired += this.logoContainer.style.display != "none" ? this.logoContainer.getBoundingClientRect().width : 0;; return totalWidthRequired; } let lastSpaceLeft = -1; const getSpaceLeft = () => { const spaceLeft = availableWidth - getCurrentWidth(); if (debug && spaceLeft !== lastSpaceLeft) { lastSpaceLeft = spaceLeft; showBalloonMessage(`Menu space left: ${spaceLeft.toFixed(0)}px`); } return spaceLeft; } } private updateCompactFoldoutItem() { if (this.root.classList.contains("compact")) { // Find items in the folding list with the highest priority // The one with the highest priority will be added to the visible container let priorityItem: HTMLElement | null = null; let priorityValue: number = -10000000; const testItem = (element: ChildNode | null) => { if (element instanceof HTMLElement) { const priority = NeedleMenu.getElementPriority(element); if (priority !== undefined && priority >= priorityValue) { // check if the element is hidden // @TODO: use computed styles const style = window.getComputedStyle(element); if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") { return; } priorityItem = element; priorityValue = priority; } } } for (let i = 0; i < this.options.children.length; i++) { testItem(this.options.children.item(i)); } for (let i = 0; i < this.optionsCompactMode.children.length; i++) { testItem(this.optionsCompactMode.children.item(i)); } if (priorityItem && !this.optionsCompactMode.contains(priorityItem)) { this.optionsCompactMode.childNodes.forEach(element => { this.options.appendChild(element); }); const item = priorityItem; this.optionsCompactMode.appendChild(item); // console.warn("In compact mode, moved item with priority " + priorityValue + " to compact foldout:", item); } else if (!priorityItem) { // console.warn("In compact mode but no item has priority, showing all items in foldout"); this.optionsCompactMode.childNodes.forEach(element => { this.options.appendChild(element); }); } } else { // console.warn("Not in compact mode but trying to update compact foldout item"); this.optionsCompactMode.childNodes.forEach(element => { this.options.appendChild(element); }); } } // private _foldoutItemVisibleInterval = 0; private ___insertDebugOptions() { window.addEventListener("keydown", (e) => { if (e.key === "p") { this.setPosition(this.root.classList.contains("top") ? "bottom" : "top"); } }); const removeOptionsButton = document.createElement("button"); removeOptionsButton.textContent = "Hide Buttons"; removeOptionsButton.onclick = () => { const optionsChildren = new Array(this.options.children.length); for (let i = 0; i < this.options.children.length; i++) { optionsChildren[i] = this.options.children[i]; } for (const child of optionsChildren) { this.options.removeChild(child); } setTimeout(() => { for (const child of optionsChildren) { this.options.appendChild(child); } }, 1000) }; this.appendChild(removeOptionsButton); const anotherButton = document.createElement("button"); anotherButton.textContent = "Toggle Logo"; anotherButton.addEventListener("click", () => { this.logoContainer.style.display = this.logoContainer.style.display === "none" ? "" : "none"; }); this.appendChild(anotherButton); } }